Problem 1.1#

Integrated Energy Grids

Problem 1.1. Analyzing solar and wind generation time series.

Note

If you have not yet set up Python on your computer, you can execute this tutorial in your browser via Google Colab. Click on the rocket in the top right corner and launch “Colab”. If that doesn’t work download the .ipynb file and import it in Google Colab.

Then install pandas and numpy by executing the following command in a Jupyter cell at the top of the notebook.

!pip install pandas numpy scipy
import pandas as pd
from scipy import fftpack
import numpy as np
import plotly.express as px
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[1], line 4
      2 from scipy import fftpack
      3 import numpy as np
----> 4 import plotly.express as px

ModuleNotFoundError: No module named 'plotly'

Data import#

In this example wind data from https://zenodo.org/record/3253876#.XSiVOEdS8l0 and solar pv data from https://zenodo.org/record/2613651#.X0kbhDVS-uV is used. The data is downloaded in csv format and saved in the ‘data’ folder. The Pandas package is used as a convenient way of managing the datasets.

data_pv = pd.read_csv('data/pv_optimal.csv',sep=';')
data_pv.index = pd.DatetimeIndex(data_pv['utc_time'])
#data_onshore = pd.read_csv('data/onshore_wind_1979-2017.csv',sep=';')
#data_onshore.index = pd.DatetimeIndex(data_onshore['utc_time'])
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-6-c0beba417152> in <cell line: 2>()
      1 import io
----> 2 df = pd.read_csv(io.StringIO(uploaded['data/pv_optimal.csv'].decode('utf-8')))
      3 data_pv = pd.read_csv('data/pv_optimal.csv',sep=';')
      4 data_pv.index = pd.DatetimeIndex(data_pv['utc_time'])
      5 #data_onshore = pd.read_csv('data/onshore_wind_1979-2017.csv',sep=';')

NameError: name 'uploaded' is not defined

The data format can now be analyzed using the .head() function showing the first lines of the data set

data_pv.head()
utc_time AUT BEL BGR BIH CHE CYP CZE DEU DNK ... MLT NLD NOR POL PRT ROU SRB SVK SVN SWE
utc_time
1979-01-01 00:00:00+00:00 1979-01-01T00:00:00Z 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1979-01-01 01:00:00+00:00 1979-01-01T01:00:00Z 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1979-01-01 02:00:00+00:00 1979-01-01T02:00:00Z 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1979-01-01 03:00:00+00:00 1979-01-01T03:00:00Z 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1979-01-01 04:00:00+00:00 1979-01-01T04:00:00Z 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0

5 rows × 33 columns

The country considerd in this document will be:

country = 'DNK'

1) Start by plotting the capacity factors for wind and solar throughout the first two weeks in January and the first two weeks in July. Do this for the most recent year for which you have available data.

px.line(data_pv['2017-01-01' :'2017-01-14'],y=country,x='utc_time',title='Capacity factor PV')
px.line(data_onshore['2017-01-01' :'2017-01-14'],y=country,x='utc_time',title='Capacity factor onshore wind')
px.line(data_pv['2017-07-01' :'2017-07-14'],y=country,x='utc_time',title='Capacity factor PV')
px.line(data_onshore['2017-07-01' :'2017-07-14'],y=country,x='utc_time',title='Capacity factor onshore wind')

2) Calculate daily, weekly and monthly mean capacity factor#

Daily, weekly and monthly means are calculated using the pandas .groupby function combined with the pandas.Grouper automaticly creationg groups with the desired size.

# Daily
daily_mean_pv = data_pv.groupby(pd.Grouper(freq='d')).mean()
daily_mean_onshore = data_onshore.groupby(pd.Grouper(freq='d')).mean()
# Weekly
weekly_mean_pv = data_pv.groupby(pd.Grouper(freq='w')).mean()
weekly_mean_onshore = data_onshore.groupby(pd.Grouper(freq='w')).mean()
# Monthly
monthly_mean_pv = data_pv.groupby(pd.Grouper(freq='M')).mean()
monthly_mean_onshore = data_onshore.groupby(pd.Grouper(freq='M')).mean()
px.line(x=daily_mean_pv['2017-01-01':'2017-12-31'][country].index,
        y=daily_mean_pv['2017-01-01':'2017-12-31'][country].values,
        title='Daily mean solar PV',labels={'x':'date', 'y':'Capacity factor'})
px.line(x=daily_mean_onshore['2017-01-01':'2017-12-31'][country].index,
        y=daily_mean_onshore['2017-01-01':'2017-12-31'][country].values,
        title='Daily mean onshore wind',labels={'x':'date', 'y':'Capacity factor'})
px.line(x=weekly_mean_pv['2017-01-01':'2017-12-31'][country].index,
        y=weekly_mean_pv['2017-01-01':'2017-12-31'][country].values,
        title='Weekly mean solar PV',labels={'x':'date', 'y':'Capacity factor'})
px.line(x=weekly_mean_onshore['2017-01-01':'2017-12-31'][country].index,
        y=weekly_mean_onshore['2017-01-01':'2017-12-31'][country].values,
        title='Weekly mean onshore wind',labels={'x':'date', 'y':'Capacity factor'})
px.line(x=monthly_mean_pv['2017-01-01':'2018-01-01'][country].index,
        y=monthly_mean_pv['2017-01-01':'2018-01-01'][country].values,
        title='Monthly mean solar PV',labels={'x':'date', 'y':'Capacity factor'})
px.line(x=monthly_mean_onshore['2017-01-01':'2018-01-01'][country].index,
        y=monthly_mean_onshore['2017-01-01':'2018-01-01'][country].values,
        title='Monthly mean onshore wind',labels={'x':'date', 'y':'Capacity factor'})

Based on the figures shown above the following trends are seen:

  • Solar PV has a clear daily pattern seen on the Capacity factor plot for the first two weeks of the year. High capacity factors are seen in the middle of the day and zero at night

  • Solar PV furthermore, has a clearly seasonal pattern, with high capacity factors in the summer, and lower in winter

  • Analyzing the daily average capacity factor of wind, fluctuations with a weekly period are seen

  • A slightly seasonal patern is also seen for wind, with more wind in the winter and less in the summer

3) Fourier power spectra#

The funcion fftpack.fft() is used to calculate the Fast Fourier Transform \(a_{\omega}=\int^T_0 X(t) e^{i \omega t}dt\) where \(X(t)\) is the time series (wind or solar) that we are analyzing.

The power spectra \(\| a_{\omega}\|^2\) is plotted to identify the dominant frequencies.

\(Fs=24\) can be selected as the sampling rate. Then, the units for the frequency will be \(1/day\).

Alternatively, \(Fs=24*365=8760\) can be selected as the sampling rate. Then, the units for the frequency will be \(1/year\).

def plot_fourier(data,title=''):
    Fs = 24 # sampling rate 24 samples per day
    t = data.index # time
    y = data.values # capacity factor
    y_fft = fftpack.fft(y) # fast fourier transform
    n = np.size(t) # number of samples
    fr = Fs/2 * np.linspace(0,1,int(n/2)) # Frequencies
    y_m = 2/n * np.square(abs(y_fft[0:np.size(fr)])) # Power
    # Plot of data
    fig = px.line(x=fr, y=y_m, labels={'x':'Frequency [1/days]', 'y':'Power'},title=title)
    fig.update_layout(xaxis=dict(range=[0,2]),yaxis=dict(range=[0,40]))
    fig.show()
data = data_onshore['2000-01-01':'2018-01-01']['DNK']
plot_fourier(data,'Onshore wind')

Analyzing this figure a clear spike is seen at frequency of 1 oscilation per day. However, some similar peaks are seen in the range from 0-0.25. Calculating the period of these 1/0.1 = 10 days, 1/0.25 = 4 days. This correspondes well with what was concluded from the initial analysis.

data = data_pv['2000-01-01':'2018-01-01']['DNK']
plot_fourier(data)

Analyzing the frequency of solar data, a very dominant peak is seen with a frequency of 1 oscilation per day. This correspondes well with the expected daily cycle of the sun.

4) Duration curve#

PV#

duration_pv = data_pv['2017':'2018']['DNK'].sort_values(ascending=False,ignore_index=True)
px.line(x=duration_pv.index,y=duration_pv.values,
        labels={'x':'Hours in year', 'y':'Capacity factor'},
        title='PV duration curve')
def curtailment_loss(duration_data,hours):
    full_load_hours_per_year = sum(duration_data)
    curtailment_loss = sum(duration_data[:hours])/full_load_hours_per_year
    print('{:.1f}% yearly production is lost if the {} hours with highest capacity factor are curtailed'.format(curtailment_loss*100,hours))
print('PV curtailment loss')
curtailment_loss(duration_pv,100)
curtailment_loss(duration_pv,1000)
PV curtailment loss
7.6% yearly production is lost if the 100 hours with highest capacity factor are curtailed
56.9% yearly production is lost if the 1000 hours with highest capacity factor are curtailed

Onshore wind#

duration_onshore = data_onshore['2017':'2018']['DNK'].sort_values(ascending=False,ignore_index=True)
px.line(x=duration_onshore.index,y=duration_onshore.values,
       labels={'x':'Hours in year', 'y':'Capacity factor'},
        title='onshore wind duration curve')
print('Onshore curtailment loss')
curtailment_loss(duration_onshore,100)
curtailment_loss(duration_onshore,1000)
Onshore curtailment loss
3.5% yearly production is lost if the 100 hours with highest capacity factor are curtailed
29.4% yearly production is lost if the 1000 hours with highest capacity factor are curtailed

The duration curves for solar and wind are fundamentally different. Solar includes more than 4000 hours of zero capacity factors (the nights!) and the maximum capacity factor is 0.7 (It is very difficult that all Denmark has a clear sky simultaneously). Wind duration curve includes a very low number of hours with zero capacity factor and a maximum of 0.9

5) Ramps#

ramps_pv = data_pv['2017':'2018']['DNK'].diff().values[1:]
fig = px.histogram(x=ramps_pv,labels={'x':'Ramp [MW/hour]'})
fig.update_layout(yaxis=dict(range=[0,500]))
ramps_onshore = data_onshore['2017':'2018']['DNK'].diff().values[1:]
px.histogram(x=ramps_onshore,labels={'x':'Ramp [MW/hour]'})
max_vals = dict(onshore=max(abs(ramps_onshore)),pv=max(abs(ramps_pv)))
highest_ramp_tech = max(max_vals)
highest_ramp_val = max_vals[highest_ramp_tech]
print('{} has the most agressive ramp with {:.1f}% change in one hour'.format(highest_ramp_tech,highest_ramp_val*100))
pv has the most agressive ramp with 30.3% change in one hour

6) Annual averages#

PV#

yearly_mean = data_pv.groupby(pd.Grouper(freq='y')).mean()
px.line(x=yearly_mean.index,y=yearly_mean['DNK'].values,
       labels={'x':'year', 'y':'Capacity factor anual average'},
        title='Solar pv capacity factor annual average')
print('Average yearly mean {:.2f}, \nStandar deviation {:.2f}'.format(yearly_mean.mean()['DNK'],yearly_mean.std()['DNK']))
Average yearly mean 0.10, 
Standar deviation 0.01

Onshore wind#

yearly_mean = data_onshore.groupby(pd.Grouper(freq='y')).mean()
px.line(x=yearly_mean.index,y=yearly_mean['DNK'].values,
        labels={'x':'year', 'y':'Capacity factor anual average'},
        title='Onshore wind capacity factor annual average')
print('Average yearly mean {:.2f}, \nStandar deviation {:.2f}'.format(yearly_mean.mean()['DNK'],yearly_mean.std()['DNK']))
Average yearly mean 0.27, 
Standar deviation 0.02

7) Repeating process for heat and electricity demand#

Data import#

data_el = pd.read_csv('data/electricity_demand.csv',sep=';')
data_el.index = pd.DatetimeIndex(data_el['utc_time'])
data_heat = pd.read_csv('data/heat_demand.csv',sep=';')
data_heat.index = pd.DatetimeIndex(data_heat['utc_time'])

Plot of capacity factors#

px.line(data_el['2015-01-01' :'2015-01-14'],y=country,x='utc_time',title='Electricity demand')
px.line(data_heat['2015-01-01' :'2015-01-14'],y=country,x='utc_time',title='Heating demand')
# Daily
daily_mean_el = data_el.groupby(pd.Grouper(freq='d')).mean()
daily_mean_heat = data_heat.groupby(pd.Grouper(freq='d')).mean()
# Weekly
weekly_mean_el = data_el.groupby(pd.Grouper(freq='w')).mean()
weekly_mean_heat = data_heat.groupby(pd.Grouper(freq='w')).mean()
# Monthly
monthly_mean_el = data_el.groupby(pd.Grouper(freq='M')).mean()
monthly_mean_heat = data_heat.groupby(pd.Grouper(freq='M')).mean()
fig = px.line(x=daily_mean_el['2015-01-01':'2015-12-31'][country].index,
        y=daily_mean_el['2015-01-01':'2015-12-31'][country].values,
        title='Daily mean electricity demand',labels={'x':'date', 'y':'Electricity demand'})
fig.show()
fig = px.line(x=weekly_mean_el['2015-01-01':'2015-12-31'][country].index,
        y=weekly_mean_el['2015-01-01':'2015-12-31'][country].values,
        title='weekly mean electricity demand',labels={'x':'date', 'y':'Electricity demand'})
fig.show()
fig = px.line(x=monthly_mean_el['2015-01-01':'2016-12-31'][country].index,
        y=monthly_mean_el['2015-01-01':'2016-12-31'][country].values,
        title='monthly mean electricity demand',labels={'x':'date', 'y':'Electricity demand'})
fig.show()
fig = px.line(x=daily_mean_heat['2015-01-01':'2015-12-31'][country].index,
        y=daily_mean_heat['2015-01-01':'2015-12-31'][country].values,
        title='Daily mean heating demand',labels={'x':'date', 'y':'Heating demand'})
fig.show()
fig = px.line(x=weekly_mean_heat['2015-01-01':'2015-12-31'][country].index,
        y=weekly_mean_heat['2015-01-01':'2015-12-31'][country].values,
        title='weekly mean heating demand',labels={'x':'date', 'y':'Heating demand'})
fig.show()
fig = px.line(x=monthly_mean_heat['2015-01-01':'2016-01-01'][country].index,
        y=monthly_mean_heat['2015-01-01':'2016-01-01'][country].values,
        title='monthly mean heating demand',labels={'x':'date', 'y':'Heating demand'})
fig.show()

Fourier#

data = data_el['2015-01-01':'2016-01-01'][country]
plot_fourier(data,'Electricity demand')
data = data_heat['2015-01-01':'2016-01-01'][country]
plot_fourier(data,'Heating demand')

Duration curves#

duration_el = data_el['2015':'2016']['DNK'].sort_values(ascending=False,ignore_index=True)
px.line(x=duration_el.index,y=duration_el.values,
        labels={'x':'Hours in year', 'y':'Demand [MW]'},
        title='Electricity demand duration curve')
duration_heat = data_heat['2015':'2016']['DNK'].sort_values(ascending=False,ignore_index=True)
px.line(x=duration_heat.index,y=duration_heat.values,
        labels={'x':'Hours in year', 'y':'Demand [MW]'},
        title='Heating demand duration curve')

Ramps#

ramsp_el = data_el['2015':'2016']['DNK'].diff().values[1:]
fig = px.histogram(x=ramsp_el,title='Electricity demand ramp distribution', labels={'x':'Ramp [MW/hour]'})
fig.show()
ramsp_heat = data_heat['2015':'2016']['DNK'].diff().values[1:]
fig = px.histogram(x=ramsp_heat,title='Heating demand ramp distribution',labels={'x':'Ramp [MW/hour]'})
fig.show()